/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 * 
 *      http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.apache.catalina.realm;

import java.security.Principal;
import java.security.cert.X509Certificate;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;

import org.apache.catalina.LifecycleException;
import org.apache.juli.logging.Log;
import org.apache.juli.logging.LogFactory;

/**
 * This class extends the CombinedRealm (hence it can wrap other Realms) to
 * provide a user lock out mechanism if there are too many failed authentication
 * attempts in a given period of time. To ensure correct operation, there is a
 * reasonable degree of synchronisation in this Realm. This Realm does not
 * require modification to the underlying Realms or the associated user storage
 * mecahisms. It achieves this by recording all failed logins, including those
 * for users that do not exist. To prevent a DOS by deliberating making requests
 * with invalid users (and hence causing this cache to grow) the size of the
 * list of users that have failed authentication is limited.
 */
public class LockOutRealm extends CombinedRealm {

	private static Log log = LogFactory.getLog(LockOutRealm.class);

	/**
	 * The number of times in a row a user has to fail authentication to be
	 * locked out. Defaults to 5.
	 */
	protected int failureCount = 5;

	/**
	 * The time (in seconds) a user is locked out for after too many
	 * authentication failures. Defaults to 300 (5 minutes).
	 */
	protected int lockOutTime = 300;

	/**
	 * Number of users that have failed authentication to keep in cache. Over
	 * time the cache will grow to this size and may not shrink. Defaults to
	 * 1000.
	 */
	protected int cacheSize = 1000;

	/**
	 * If a failed user is removed from the cache because the cache is too big
	 * before it has been in the cache for at least this period of time (in
	 * seconds) a warning message will be logged. Defaults to 3600 (1 hour).
	 */
	protected int cacheRemovalWarningTime = 3600;

	/**
	 * Users whose last authentication attempt failed. Entries will be ordered
	 * in access order from least recent to most recent.
	 */
	protected Map<String, LockRecord> failedUsers = null;

	/**
	 * Prepare for the beginning of active use of the public methods of this
	 * component. This method should be called before any of the public methods
	 * of this component are utilized. It should also send a LifecycleEvent of
	 * type START_EVENT to any registered listeners.
	 * 
	 * @exception LifecycleException
	 *                if this component detects a fatal error that prevents this
	 *                component from being used
	 */
	public void start() throws LifecycleException {
		// Configure the list of failed users to delete the oldest entry once it
		// exceeds the specified size
		failedUsers = new LinkedHashMap<String, LockRecord>(cacheSize, 0.75f,
				true) {
			protected boolean removeEldestEntry(
					Map.Entry<String, LockRecord> eldest) {
				if (size() > cacheSize) {
					// Check to see if this element has been removed too quickly
					long timeInCache = (System.currentTimeMillis() - eldest
							.getValue().getLastFailureTime()) / 1000;

					if (timeInCache < cacheRemovalWarningTime) {
						log.warn(sm.getString("lockOutRealm.removeWarning",
								eldest.getKey(), Long.valueOf(timeInCache)));
					}
					return true;
				}
				return false;
			}
		};

		super.start();
	}

	/**
	 * Return the Principal associated with the specified username and
	 * credentials, if there is one; otherwise return <code>null</code>.
	 * 
	 * @param username
	 *            Username of the Principal to look up
	 * @param credentials
	 *            Password or other credentials to use in authenticating this
	 *            username
	 */
	public Principal authenticate(String username, byte[] credentials) {
		if (isLocked(username)) {
			// Trying to authenticate a locked user is an automatic failure
			registerAuthFailure(username);

			log.warn(sm.getString("lockOutRealm.authLockedUser", username));
			return null;
		}

		Principal authenticatedUser = super.authenticate(username, credentials);

		if (authenticatedUser == null) {
			registerAuthFailure(username);
		} else {
			registerAuthSuccess(username);
		}
		return authenticatedUser;
	}

	/**
	 * Return the Principal associated with the specified username, which
	 * matches the digest calculated using the given parameters using the method
	 * described in RFC 2069; otherwise return <code>null</code>.
	 * 
	 * @param username
	 *            Username of the Principal to look up
	 * @param clientDigest
	 *            Digest which has been submitted by the client
	 * @param nOnce
	 *            Unique (or supposedly unique) token which has been used for
	 *            this request
	 * @param realm
	 *            Realm name
	 * @param md5a2
	 *            Second MD5 digest used to calculate the digest : MD5(Method +
	 *            ":" + uri)
	 */
	public Principal authenticate(String username, String clientDigest,
			String once, String nc, String cnonce, String qop,
			String realmName, String md5a2) {

		if (isLocked(username)) {
			// Trying to authenticate a locked user is an automatic failure
			registerAuthFailure(username);

			log.warn(sm.getString("lockOutRealm.authLockedUser", username));
			return null;
		}

		Principal authenticatedUser = super.authenticate(username,
				clientDigest, once, nc, cnonce, qop, realmName, md5a2);

		if (authenticatedUser == null) {
			registerAuthFailure(username);
		} else {
			registerAuthSuccess(username);
		}
		return authenticatedUser;
	}

	/**
	 * Return the Principal associated with the specified username and
	 * credentials, if there is one; otherwise return <code>null</code>.
	 * 
	 * @param username
	 *            Username of the Principal to look up
	 * @param credentials
	 *            Password or other credentials to use in authenticating this
	 *            username
	 */
	public Principal authenticate(String username, String credentials) {
		if (isLocked(username)) {
			// Trying to authenticate a locked user is an automatic failure
			registerAuthFailure(username);

			log.warn(sm.getString("lockOutRealm.authLockedUser", username));
			return null;
		}

		Principal authenticatedUser = super.authenticate(username, credentials);

		if (authenticatedUser == null) {
			registerAuthFailure(username);
		} else {
			registerAuthSuccess(username);
		}
		return authenticatedUser;
	}

	/**
	 * Return the Principal associated with the specified chain of X509 client
	 * certificates. If there is none, return <code>null</code>.
	 * 
	 * @param certs
	 *            Array of client certificates, with the first one in the array
	 *            being the certificate of the client itself.
	 */
	public Principal authenticate(X509Certificate[] certs) {
		String username = null;
		if (certs != null && certs.length > 0) {
			username = certs[0].getSubjectDN().getName();
		}

		if (isLocked(username)) {
			// Trying to authenticate a locked user is an automatic failure
			registerAuthFailure(username);

			log.warn(sm.getString("lockOutRealm.authLockedUser", username));
			return null;
		}

		Principal authenticatedUser = super.authenticate(certs);

		if (authenticatedUser == null) {
			registerAuthFailure(username);
		} else {
			registerAuthSuccess(username);
		}
		return authenticatedUser;
	}

	/**
	 * Unlock the specified username. This will remove all records of
	 * authentication failures for this user.
	 * 
	 * @param username
	 *            The user to unlock
	 */
	public void unlock(String username) {
		// Auth success clears the lock record so...
		registerAuthSuccess(username);
	}

	/*
	 * Checks to see if the current user is locked. If this is associated with a
	 * login attempt, then the last access time will be recorded and any attempt
	 * to authenticated a locked user will log a warning.
	 */
	private boolean isLocked(String username) {
		LockRecord lockRecord = null;
		synchronized (this) {
			lockRecord = failedUsers.get(username);
		}

		// No lock record means user can't be locked
		if (lockRecord == null) {
			return false;
		}

		// Check to see if user is locked
		if (lockRecord.getFailures() >= failureCount
				&& (System.currentTimeMillis() - lockRecord
						.getLastFailureTime()) / 1000 < lockOutTime) {
			return true;
		}

		// User has not, yet, exceeded lock thresholds
		return false;
	}

	/*
	 * After successful authentication, any record of previous authentication
	 * failure is removed.
	 */
	private synchronized void registerAuthSuccess(String username) {
		// Successful authentication means removal from the list of failed users
		failedUsers.remove(username);
	}

	/*
	 * After a failed authentication, add the record of the failed
	 * authentication.
	 */
	private void registerAuthFailure(String username) {
		LockRecord lockRecord = null;
		synchronized (this) {
			if (!failedUsers.containsKey(username)) {
				lockRecord = new LockRecord();
				failedUsers.put(username, lockRecord);
			} else {
				lockRecord = failedUsers.get(username);
				if (lockRecord.getFailures() >= failureCount
						&& ((System.currentTimeMillis() - lockRecord
								.getLastFailureTime()) / 1000) > lockOutTime) {
					// User was previously locked out but lockout has now
					// expired so reset failure count
					lockRecord.setFailures(0);
				}
			}
		}
		lockRecord.registerFailure();
	}

	/**
	 * Get the number of failed authentication attempts required to lock the
	 * user account.
	 * 
	 * @return the failureCount
	 */
	public int getFailureCount() {
		return failureCount;
	}

	/**
	 * Set the number of failed authentication attempts required to lock the
	 * user account.
	 * 
	 * @param failureCount
	 *            the failureCount to set
	 */
	public void setFailureCount(int failureCount) {
		this.failureCount = failureCount;
	}

	/**
	 * Get the period for which an account will be locked.
	 * 
	 * @return the lockOutTime
	 */
	public int getLockOutTime() {
		return lockOutTime;
	}

	/**
	 * Set the period for which an account will be locked.
	 * 
	 * @param lockOutTime
	 *            the lockOutTime to set
	 */
	public void setLockOutTime(int lockOutTime) {
		this.lockOutTime = lockOutTime;
	}

	/**
	 * Get the maximum number of users for which authentication failure will be
	 * kept in the cache.
	 * 
	 * @return the cacheSize
	 */
	public int getCacheSize() {
		return cacheSize;
	}

	/**
	 * Set the maximum number of users for which authentication failure will be
	 * kept in the cache.
	 * 
	 * @param cacheSize
	 *            the cacheSize to set
	 */
	public void setCacheSize(int cacheSize) {
		this.cacheSize = cacheSize;
	}

	/**
	 * Get the minimum period a failed authentication must remain in the cache
	 * to avoid generating a warning if it is removed from the cache to make
	 * space for a new entry.
	 * 
	 * @return the cacheRemovalWarningTime
	 */
	public int getCacheRemovalWarningTime() {
		return cacheRemovalWarningTime;
	}

	/**
	 * Set the minimum period a failed authentication must remain in the cache
	 * to avoid generating a warning if it is removed from the cache to make
	 * space for a new entry.
	 * 
	 * @param cacheRemovalWarningTime
	 *            the cacheRemovalWarningTime to set
	 */
	public void setCacheRemovalWarningTime(int cacheRemovalWarningTime) {
		this.cacheRemovalWarningTime = cacheRemovalWarningTime;
	}

	protected class LockRecord {
		private AtomicInteger failures = new AtomicInteger(0);
		private long lastFailureTime = 0;

		public int getFailures() {
			return failures.get();
		}

		public void setFailures(int theFailures) {
			failures.set(theFailures);
		}

		public long getLastFailureTime() {
			return lastFailureTime;
		}

		public void registerFailure() {
			failures.incrementAndGet();
			lastFailureTime = System.currentTimeMillis();
		}
	}
}
